Skip to content

feat(config): layered config tool filter — config_denied over user toggles#468

Open
nlaurance wants to merge 9 commits into
smart-mcp-proxy:mainfrom
nlaurance:feat/config-tool-allowlist
Open

feat(config): layered config tool filter — config_denied over user toggles#468
nlaurance wants to merge 9 commits into
smart-mcp-proxy:mainfrom
nlaurance:feat/config-tool-allowlist

Conversation

@nlaurance
Copy link
Copy Markdown

@nlaurance nlaurance commented May 14, 2026

Summary

  • Replaces the BBolt-mutating applyConfigToolFilter (which overwrote user-owned Disabled flags on every reconnect) with a pure evaluation-time config layer
  • enabled_tools / disabled_tools in mcp_config.json are now enforced without touching user preferences, emitting spurious audit events, or silently re-enabling tools the user manually disabled

What changed

Backend

  • ServerConfig.IsToolAllowedByConfig(toolName) — pure config helper
  • Runtime.IsToolConfigDenied(serverName, toolName) — evaluation-time check, no BBolt writes
  • isToolCallable (MCP enforcement chokepoint) checks config layer before BBolt record.Disabled — config-denied tools are hard off across all paths (call_tool_*, retrieve_tools, upstream_servers tool counts)
  • SetAllToolsEnabled skips config-denied tools so bulk Enable All never sets a misleading Disabled=false on a hard-off tool
  • GET /api/v1/servers/{id}/tools exposes config_denied: true per tool
  • POST /api/v1/servers/{id}/tools/{tool}/enabled returns HTTP 409 when trying to enable a config-denied tool
  • UpstreamRecord now persists EnabledTools / DisabledTools through BBolt round-trips (bug fix)

Frontend

  • Tools with config_denied: true show a locked by config badge and a read-only label instead of a toggle
  • Quarantine panel hides the Approve button for config-denied pending/changed tools (shows a lock badge instead)
  • Tool interface in api.ts now properly types disabled, config_denied, approval_status, etc.

Effective visibility model

callable = configAllows(tool) AND userEnabled(tool)

Config can only restrict further — it can never force a tool back on against the user.

Test Plan

  • Unit tests: go test ./internal/config/... ./internal/runtime/... ./internal/server/... ./internal/httpapi/... ./internal/storage/... — all pass
  • Frontend: cd frontend && npm run build — clean build
  • Config with enabled_tools: ["list_issues"]create_issue unreachable via MCP, shows locked in UI
  • Config with disabled_tools: ["delete_repo"]delete_repo unreachable, locked in UI
  • User disabling a tool in the UI survives server reconnect (no longer reset by config)
  • POST /api/v1/servers/{id}/tools/delete_repo/enabled with {"enabled": true} → 409 Conflict
  • Pending/changed quarantine tool that is also config-denied shows lock badge, not Approve button

🤖 Generated with Claude Code

…enylist

Adds two mutually exclusive fields to ServerConfig that let operators
declare tool visibility statically in mcp_config.json rather than
having to call the API or CLI after every fresh install.

  enabled_tools: ["list_issues", "get_issue"]   // allowlist — only these visible
  disabled_tools: ["delete_repo", "force_push"]  // denylist  — hide these, allow rest

Config validation rejects a server that has both fields set.

On every applyDifferentialToolUpdate (server connect / tool refresh),
applyConfigToolFilter walks the in-memory config, computes the desired
enabled/disabled state for each discovered tool, and calls
setToolEnabledNoEmit to persist it in BBolt. All existing enforcement
paths (isToolCallable, retrieve_tools pre-ranking, call_tool_*) pick
up the change automatically with no further modifications.

Five unit tests cover: allowlist disables unlisted tools, allowlist
re-enables a tool moved back into the list, denylist disables listed
tools, no-op when neither field is set, and end-to-end integration
through applyDifferentialToolUpdate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Dumbris
Copy link
Copy Markdown
Member

Dumbris commented May 15, 2026

Thanks @nlaurance — clean implementation and genuinely thorough tests. I support the idea of config-based tool filtering — declaring tool visibility in mcp_config.json is the right capability for fresh installs and gitops setups, and the allowlist's fail-closed posture against tool-surface growth is exactly in our threat model.

My concern is purely the interaction model with the per-tool enable/disable that recently landed (#463 Web UI, #447, REST/CLI SetToolEnabled). applyConfigToolFilter writes config intent into the same ToolApprovalRecord.Disabled BBolt flag those paths own, on every applyDifferentialToolUpdate (i.e. every reconnect). Concretely this means:

  • There's no durable provenanceToolApprovalRecord has no source field, so after the first write a config-disabled tool is indistinguishable from a user-disabled one. updatedBy="config" is dropped.
  • A tool the user manually disabled in the UI that's in enabled_tools gets silently re-enabled on every reconnectTestApplyConfigToolFilter_EnabledTools_ReEnablesTool pins this as intended, but that's the behavior I'd push back on.
  • It emits a tool_disabled activity event on every reconnect, polluting the audit trail.
  • A config-denied tool keeps its pending/changed quarantine status, so upstream inspect/approve still invites the user to approve a tool config will keep hidden.

Definition I'd like us to agree on

For me, config-based filtering should mean hard-off for unwanted tools, and for that to be solid it has to be transparent, predictable, and surfaced in the UI and CLI — never a silent toggle that snaps back. Precisely:

  • Effective visibility = configAllows(tool) AND userEnabled(tool). Config can only restrict further; it can never force a tool back on against the user.
  • A tool denied by config (in disabled_tools, or absent from a non-empty enabled_tools allowlist) is HARD OFF.
  • Config-denied tools are shown in the Web UI and CLI as locked + badged ("disabled by mcp_config.json") — toggle is read-only, and the server rejects an override attempt from a stale client.
  • A config-denied tool that is also pending/changed in quarantine shows the config lock, not an "approve me" affordance.
  • Config-allowed tools remain normally user-toggleable.

Spec use cases

  1. disabled_tools: ["delete_repo"]delete_repo HARD OFF, UI shows it locked+badged, upstream inspect shows SOURCE=config. User cannot enable it.
  2. enabled_tools: ["list_issues"], server also exposes create_issuecreate_issue is HARD OFF (implicit deny), locked+badged. list_issues is a normal toggle the user may still disable.
  3. User disables list_issues (in enabled_tools) via the UI → it stays disabled across reconnects; config does not re-enable it.
  4. Operator has an allowlist set and the upstream server adds new tools → new tools are denied by default, no manual action needed.
  5. A pending tool that's also config-denied → shown as config-locked, not surfaced for approval.

Implementation-wise this means config should override the stored DB enable state at evaluation time rather than overwriting it: evaluate the config layer in isToolCallable (the single chokepoint that already covers all enforcement paths + code_execution), keep record.Disabled exclusively user-owned, expose a derived config_managed field on the tools API so the UI/CLI can lock+badge, and stop emitting per-reconnect audit events.

Does this definition work for you? If we're aligned on it, I'm happy to pair on this PR and convert the current behavior to override the DB option after config read along these lines — the config fields + mutual-exclusion validation you've already written are solid and stay as-is.

nlaurance and others added 8 commits May 18, 2026 07:16
…lConfigDenied

Replace the BBolt-writing applyConfigToolFilter function (which overwrote
user preferences on every reconnect, emitted spurious audit events, and had
no provenance) with an evaluation-time IsToolConfigDenied method that:

- Reads config at call time, never writes to BBolt
- Preserves user-set tool preferences and audit trail
- Enables separation of concerns: config vs user intent

Key changes:
- Delete applyConfigToolFilter from tool_quarantine.go (63 lines removed)
- Add IsToolConfigDenied(serverName, toolName string) bool to Runtime
- Remove applyConfigToolFilter call from lifecycle.go
- Rewrite tool_config_filter_test.go: 5 new tests for IsToolConfigDenied
  * AllowList: tools not listed are denied
  * DenyList: listed tools are denied
  * NoFilter: all tools allowed when config has no filter
  * UnknownServer: returns false for missing servers
  * UserDisabledPreserved: BBolt state is independent from config layer

All 198 runtime tests pass. No behavior change to actual tool visibility—
the config layer is now just evaluated at call time instead of persisted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add IsToolConfigDenied delegation on *Server and insert a config-layer
check in isToolCallable so tools denied by enabled_tools/disabled_tools
in the server config are hard-off at MCP call time, evaluated at
runtime without touching BBolt storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…fig-denied tools

- Add ConfigDenied bool field to contracts.Tool (json: config_denied,omitempty)
- Enrich config_denied in handleGetServerTools via IsToolConfigDenied interface
- Return HTTP 409 in handleSetToolEnabled when req.Enabled is true for a config-denied tool
- Remove debug fmt.Printf lines from enrichment loop; use logger.Debug instead

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extended the Tool interface to include runtime fields that the backend
sends and Vue components use: server_name, schema, usage, last_used,
approval_status, disabled, and config_denied. This allows proper typing
of these fields in the frontend instead of using unsafe casts.

Simplified type assertions in isToolConfigDenied and isToolEnabled
functions to use the properly-typed Tool interface directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nlaurance nlaurance changed the title feat(config): enabled_tools/disabled_tools per-server allowlist/denylist in mcp_config.json feat(config): layered config tool filter — config_denied over user toggles May 18, 2026
@nlaurance
Copy link
Copy Markdown
Author

now rebased with changes suggested

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants